You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Continues #348. That PR was closed automatically when the branch was renamed feat/outpost-api-client → release/v3.0.0; GitHub closes a pull request whose head
branch is renamed. No commits or review history were lost — the discussion on #348 is
still readable, and this PR points at the same tip.
This is a major version: every MCP tool name changes. Outpost support, and a rework of the MCP surface so that "let an agent read, but ask me before it changes anything" is a rule you can actually write.
Breaking change — read this first
Two things happen to tool names, and they land together on purpose.
1. Event Gateway product tools move from hookdeck_ to gateway_.hookdeck_login keeps its prefix — it is a Hookdeck operation whichever product's server you are in.
2. Every resource tool splits by what it does. A tool used to carry an action enum mixing list with delete. MCP clients grant permission per tool name, with no ability to match on arguments, so such a tool had to be allowed or denied whole.
Outpost splits the same way: outpost_tenants_read / _write, outpost_destinations_read / _write, outpost_events_read / _write, outpost_config_read / _write, outpost_publish_write, and read-only outpost_attempts_read, outpost_topics_read, outpost_destination_types_read, outpost_metrics_read, outpost_status_read.
Per-tool permission grants and allowedTools entries must be updated once. They do not survive a rename and MCP has no migration mechanism.
The upside is the reason for the churn. "Allow all reads, prompt on anything that changes data" is now a single rule — mcp__hookdeck-gateway__*_read — where before it could not be expressed at all. Both renames land in one upgrade deliberately: splitting after GA would have meant a second re-grant.
Two deliberate exceptions, both their own tool so the suffix stays honest:
gateway_connections_pause — pause/unpause stay available in read-only mode. Read-only MCP is the incident-investigation tool, and pausing a misbehaving connection is the natural end of an investigation.
hookdeck_projects_use — switching the active project changes what every other tool returns, but it is not a write to project data.
What's in it
Outpost support — a hookdeck outpost … command group covering the managed Outpost API (tenants, destinations, events, attempts, publish, topics, destination types, metrics, operator config, custom domain, status), plus hookdeck outpost mcp.
MCP write mode — --allow-write (or HOOKDECK_MCP_ALLOW_WRITE), off by default. Write tools are not registered at all without it, so an agent is never offered something it cannot do; asking for a gated action on a _read tool names the flag that would enable it. --read-only is accepted explicitly and wins if both are passed.
transformations run is a read, checked against the API rather than assumed: a run creates no execution record and returns no execution id. Gating it would leave a session able to read transformation code but unable to try it, which is the debugging work read-only mode exists for.
Bulk operations — gateway_bulk_read / gateway_bulk_write across five bulk families (events retry, ignored events retry, requests retry, events cancel, requests cancel). plan estimates what an operation would touch without running it, and is a read, so blast radius can be sized with no write access at all. Filters are validated locally against what each operation declares, because the API silently ignores a filter it does not recognise — and "ignored filter" on a bulk retry means running across everything the remaining filters matched.
Shared MCP core — the product-agnostic machinery (input parsing, response envelopes, error translation, auth, login/projects tools, telemetry, and the action/write-gating model) lives in pkg/mcpcore, so the Gateway and Outpost servers no longer carry two copies.
Not in this release
Platform management — organizations, project CRUD, custom domains, organization API keys — is built but parked. Probing the live API with all four Hookdeck credentials showed every platform route requires an organization API key: a CLI session key gets 401 on all of them, including reading the project it is currently pinned to. Shipping it would have meant publishing commands and tools that answer "Unauthorized" to anyone not carrying an org key.
The work is preserved on branch platform-api pending a decision on the permissions model. hookdeck project list and hookdeck project use are unaffected.
API key management is not available through MCP, in any form. A key is a credential; an agent able to mint one could grant itself access the server would otherwise refuse.
Testing
Both servers have a coverage gate that fails when an action ships without a test making a successful call — not merely a test proving it is blocked:
pkg/gateway/mcp: 55 of 55 actions covered, including all 28 write actions (was 7)
pkg/outpost/mcp: every action covered
Tests assert the request that goes on the wire — method, path, query and body — because a stub server answers whatever it is asked, and every wire-shape defect found during development would have passed a "did not error" assertion. That caught real things: upsert is a PUT to the collection rather than to an id, update must omit fields the caller did not mention, and the MCP-to-API parameter renames (connection_id → webhook_id, connection_ids → webhook_ids, filter_status → status).
Four invariants are pinned by tests rather than by review:
TestToolSurfaceIsWhatWeThinkItIs renders the entire advertised surface in both modes as a golden list, so any tool added, removed, renamed or re-annotated shows as a diff a reviewer has to agree to.
No tool mixes reads and writes, and every name ends in _read or _write bar the documented exceptions — otherwise *_read is not a usable grant.
Read tools are byte-identical in both modes. Write mode adds tools; it never grows an existing read tool.
TestAgentFacingTextNamesOnlyRealTools reads every tool name mentioned in text an agent sees — descriptions, input schemas, help overview, every help topic — and requires it to exist. Added after manual QA found help text advertising hookdeck_projects, a name the split had removed; the guard then found six more.
Also: a live Outpost smoke suite that previously ran nowhere is now on a nightly schedule.
Manual QA against a live project found two defects, both fixed here: gateway_connections_write could not create a connection without an explicit rules array, and help text named tools that no longer existed.
Acceptance slice 0 needed its timeout raised from 12m to 30m. The MCP tests in it had been failing instantly on stale tool names, which hid the slice's real cost; working, they are ~10 minutes of live API calls.
(#424 and #428 are closed by keywords in their own commits. #363 is only partly addressed by this
branch — the write-only-props-in-read-only-mode half is gone with the compound tools, and its
mirror is fixed here — so it is deliberately left open for a re-read against the current surface.)
First phase of Outpost support (#346): the API client layer that the
`hookdeck outpost` commands and MCP server will be built on. No user-facing
commands yet.
Client:
- Outpost API base URL, a separate client instance, and config resolution
including a hidden --outpost-api-base for dev
- IsOutpostProject alongside IsGatewayProject
- Per-resource methods for tenants, destinations, events, attempts, retry,
publish, topics, destination types, metrics, managed config, custom domain
and status
- Destination type schemas fetched and cached per API host and project, so
--type validation follows the API rather than a hardcoded list
Two shapes worth calling out. The `topics` field is a union — either "*" or an
array — so it decodes through a dedicated type rather than []string. Publish
takes a Project API key as a bearer token, which the stored CLI key cannot
satisfy, so it sends through a clone with no stored credential.
Live tests (build tag `outpostlive`) exercise the client against a real
project and found two bugs that the stub-based unit tests could not:
- destination-type `options` is [{label, value}], not []string; the stub
fixture had encoded the wrong shape, which is why the unit tests passed
- only HTTP 200 was treated as success. The Event Gateway API answers 200 to
everything, so this never surfaced, but Outpost uses 201 on create and 202
on publish/retry, so every write failed. Fixed with an opt-in
Client.AcceptAnySuccessStatus, set on the Outpost client only
Docs: README gains a key capability matrix and a way to tell which credential
you hold; AGENTS.md gains the same diagnosis for agents plus the acceptance
key table. The config field named api_key holds a CLI client key regardless of
origin, which is easy to misread.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
Adds the `hookdeck outpost` group with an Outpost-project gate mirroring the
Gateway one, plus the tenant command tree: list, get, upsert, delete, token
and portal.
The gate matters for the error message rather than for safety. Pointing an
outpost command at a Gateway project otherwise returns a 404, which reads as
"no such tenant" instead of "you are on the wrong project"; it now says which
type the project is and how to switch.
Tenants are created through upsert because their IDs are chosen by the caller
rather than generated. Delete names the destination count in its prompt, since
that is the part most likely to have been forgotten.
`--id` joins the empty-value guard list. It is a filter rather than an
identifier, but the failure is worse: an empty value drops the filter, so
`--id "$UNSET"` silently widens the query to everything rather than narrowing
it. Verified against a real project, along with the no-terminal delete path.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
Adds `hookdeck outpost destination` — list, get, create, update, delete, enable
and disable — with --tenant-id persistent across the group, since every
destination endpoint is tenant-scoped.
Deviation from the plan worth noting. The plan called for flat per-field flags
(--config-url, --credential-secret). That is not implementable here: Cobra
registers flags at init, but destination fields differ per type and are only
known after fetching the schema, so declaring them would mean a network call
before every command could parse its own arguments. Config and credentials are
repeatable key=value pairs instead (--config url=https://example.com), with
--config-file and --credentials-file as escape hatches.
The schema is still used, for validation rather than flag registration: unknown
keys, missing required fields, values outside a declared option set and values
failing a declared pattern are all rejected before the request, naming the
exact flag to fix and pointing at `destination-type get <type>` for the field
list. Per AGENTS.md, a schema that cannot be fetched warns and continues rather
than blocking a valid command.
Update reads the existing destination to recover its type, so callers do not
have to repeat --type just to get their config validated, and refuses an update
with no fields rather than silently succeeding.
Verified against a real project: create, list, get, update, enable, disable,
schema validation, unknown type, missing tenant, and the no-terminal delete.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
Adds `hookdeck outpost destination-type list|get`, and makes
`destination create --type <type> --help` list that type's fields.
Dynamic help is the answer to the discoverability cost of key=value config
flags: `--config` alone cannot say which keys are valid, because the fields
belong to the Outpost deployment rather than the CLI. Cobra parses flags before
running the help function, so once a user has named a --type we can show
exactly the fields it accepts, sourced from the same schema used for
validation.
Three properties this holds to:
- Plain `--help` is untouched and needs no network or credentials. It only
gains a line saying how to get per-type detail.
- Cache first. The schema cache is already per host and project with a 24h TTL,
so the warm path is a local file read. A cold cache allows one request bounded
at 2s, and only when credentials exist; unauthenticated, offline and cold-cache
runs all fall back to static help rather than erroring or hanging.
- REFERENCE.md cannot be affected. The generator reads Long and the flag
definitions directly and never invokes help, so generated docs stay identical
whatever is cached locally. Verified with warm and cold caches, and pinned by
a test asserting help never rewrites Long or flag usage.
One non-obvious detail: Cobra returns flag.ErrHelp before running the
cobra.OnInitialize hooks, so on the help path the config is not loaded yet.
Without initialising it the client has no base URL or project and the cache —
keyed on both — is never found.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
`--config a.b=c` now builds a nested object. Flat keys are unchanged, so this
is a no-op for every destination type that exists today.
It is added now because of what the key=value design is for. Outpost's
destination types are defined by the deployment rather than the CLI, which is
why fields are not hardcoded — but that cuts both ways: a nested type could
ship server-side just as easily as a new flat one. Flat-only parsing would
leave such a type impossible to create until we shipped a CLI fix, which is
precisely the failure the design exists to avoid. Paths cost nothing today and
remove that cliff.
The syntax follows Helm's --set (a.b.c=v, with a file as the escape hatch)
rather than being invented here. A literal dot can be escaped as `a\.b`; no
field key in either product contains one today, so that exists to avoid a
corner rather than to solve a present problem.
Validation now skips nested values instead of rejecting them. The schema
describes flat fields, so it cannot say whether a nested shape is valid, and
per AGENTS.md a client-side guess must not block a command the API would
accept.
Checked against the live API while deciding this: all 9 destination types are
flat and every value is a string on the wire. The /destination-types endpoint
reports some fields as key_value_map or checkbox, but those are form-rendering
hints — sending custom_headers as an object returns it normalised to a
JSON-encoded string, identical to sending a string. Context in #347.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
…d status commands
Completes the outpost command tree.
- event list/get/retry, attempt list/get — the debugging surface. Attempts carry
the response code the destination returned, which is what you actually need
when delivery is failing.
- publish — the one command with different auth. The publish API takes a Project
API key as a bearer token and does not accept the credentials `hookdeck login`
stores, so it has its own --api-key defaulting to HOOKDECK_API_KEY. Without
one it fails with an actionableError explaining why, rather than surfacing a
bare 401 that the generic handler would rewrite into "your API key is invalid
or expired" — true but useless, since the stored key is never valid here.
- topic list — reports the fix when no topics are configured, since an empty
list leaves the project unable to deliver anything.
- metrics events/attempts — reports when results were truncated at the row
limit, so a partial answer is not mistaken for a complete one.
- config get/set and config custom-domain — set takes KEY=VALUE arguments with
--unset to restore a default, and --dry-run showing before/after per key.
These settings apply to every tenant in the project, so the diff matters.
- status — the first thing to check when configuration changes have not taken
effect yet.
Attempt list uses the tenant-scoped route when exactly one tenant and one
destination are given, and the general one otherwise; results are identical
either way.
Verified against a real project: publish end to end with matched destinations,
retry recorded as a manual second attempt, dry-run confirmed not to apply,
pagination, and the missing-key error path.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
Adds test/acceptance/outpost_test.go behind the `outpost` build tag, covering
tenant and destination lifecycles, destination types, publish and inspect,
metrics, config, and the validation error paths.
The suite needs its own project. Every `hookdeck outpost` command requires an
Outpost project, so the Gateway keys the existing slices use would be rejected
by the project gate before any request is made. NewOutpostCLIRunner reads
HOOKDECK_CLI_OUTPOST_TESTING_API_KEY, which is a Project API key doing double
duty: exchanged via `hookdeck ci` for the CLI credentials most commands use, and
passed directly to `outpost publish`, which does not accept CLI credentials.
The Gateway-rejection test lives in the gateway slice rather than this one,
because asserting that a Gateway project is refused needs a Gateway project.
Two things worth noting for anyone extending this:
- Error assertions read stdout, not stderr. The CLI prints errors to stdout
today (see #340, which tracks moving them); `go run` writes its own "exit
status 1" to stderr, so asserting there passes vacuously. The tests are
commented so this fails loudly if the contract changes rather than silently
checking the wrong stream.
- Tenants are uniquely named per run and removed in t.Cleanup. The project is
shared between local runs and CI, and a failed run can leave data behind, so
nothing assumes it starts empty.
Both suites were run locally against the real project before committing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
Adds the generated REFERENCE.md block for the outpost command tree, a README
section, and the publish key exception to AGENTS.md.
The generator's table of contents is a hand-maintained list rather than being
derived from headings, so Outpost was added there — along with Metrics, which
had been missing since it was introduced.
Both docs lead with the two things that are genuinely surprising: config and
credential fields are key=value pairs because they belong to the Outpost
deployment rather than the CLI, and publish needs a Project API key because it
is the one command that does not accept the credentials `hookdeck login`
stores.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
Raises CLI-level acceptance coverage from 22/30 to 26/30 leaf commands. All
four reuse data the existing tests already create, so they add coverage without
adding setup.
The tenant token assertion checks shape rather than contents — three JWT
segments, and that the raw tenant id is not readable in it. The token is a real
credential, so a test should not print or match on its payload.
The four commands still uncovered are the tenant portal and its custom domain.
They are not omitted casually: `custom-domain set` configures a real DNS-verified
hostname on the shared project, and `tenant portal` returns 404 until one exists.
Covering them safely needs a dedicated throwaway domain. They are the least
proven surface and should be called out as such in beta release notes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
The MCP server scaffolding in pkg/gateway/mcp was written for one product but
almost none of it is Gateway-specific. Move the shared parts into a new
pkg/mcpcore so a second Hookdeck MCP server can reuse them instead of forking
them: input parsing, the data/meta response envelope, API error translation,
the auth guard, the JSON Schema helpers, project display resolution, the login
and projects tools, and the server/telemetry scaffolding.
Each product supplies its own identity, tool-name prefix, API client and tool
list through mcpcore.Options. Everything the login and projects tools say about
"the login tool" or "the projects tool" now comes from that prefix, so a second
server cannot tell an agent to call a tool that does not exist in its session.
Help topic normalisation takes the prefix as a parameter for the same reason.
Also adds two things the second server needs, kept here so there is only one
implementation of each:
- TranslateAPIError handles 403 distinctly from 401. "Check your API key" is
the wrong advice when the credential is valid but not permitted.
- RequireWrite(enabled, action) guards a write action on a server started in
read-only mode.
And an option the Gateway does not use: Options.ProjectFilter restricts which
project types the projects tool lists and will switch to, so a server cannot be
pointed at a project it has no API for. Gateway leaves it unset and keeps its
current behaviour.
Gateway behaviour is unchanged: same tool names, descriptions, schemas and
response shapes. pkg/gateway/mcp now holds only its tool definitions and
resource handlers. Unit tests for the moved code moved with it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
`hookdeck outpost mcp` exposes Outpost as MCP tools: tenants, their
destinations, published events, delivery attempts, topics, destination type
schemas, metrics, project configuration and deployment status. Tools are
prefixed outpost_ so this server and `hookdeck gateway mcp` can be configured
in the same client.
The server starts read-only. The gate is the schema rather than a runtime
check: in read-only mode the write actions are absent from each tool's action
enum and from its description, so an agent is never told about an action it
cannot use, and a tool whose every action is a write is not registered at all
rather than registered to always fail. A guard in each handler backs that up
for a client that calls one anyway. --allow-write enables the rest, and is also
read from HOOKDECK_MCP_ALLOW_WRITE, with the flag winning. A bare --read-only
is accepted for the many users who type it out of habit; it wins over
--allow-write.
Two actions that only read are gated with the writes: `outpost_tenants token`
mints a tenant-scoped access token and `outpost_tenants portal` returns a URL
granting access to a tenant's portal. Both hand back a reusable credential, so
a read/write split drawn on HTTP methods alone would leave a read-only session
able to produce them at will. outpost_help says so, along with the current mode
and how to change it.
Publishing needs a Hookdeck Project API key, which the credentials stored by
`hookdeck login` cannot substitute for. Without one the publish tool is not
registered, and outpost_help explains why.
Notes on wiring:
- The server is built on the Outpost API client and mutates that one, so
`outpost_projects use` moves the client the later calls actually go
through. Listing projects and validating credentials are account-level
requests that the Outpost host does not serve, so those go through a
separate account client, which is kept in step on a project switch or a
login. mcpcore gained an AccountClient option for this.
- `outpost_projects` only lists, and only switches to, Outpost projects. A
Gateway project would leave every later call failing.
- The MCP stdout hygiene and authentication fallback in root.go now apply to
any `<group> mcp` command, and name the login tool that exists in that
session.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
…ry server
Login and project switching are Hookdeck platform operations, not Gateway or
Outpost ones. You log in to Hookdeck; you switch a Hookdeck project. So both
servers now expose hookdeck_login and hookdeck_projects, while product tools
keep their own prefix: outpost_tenants, hookdeck_connections.
Outpost previously named these outpost_login and outpost_projects. The original
reasoning was collision avoidance when both servers are configured in one
client, which does not hold up: it is the same operation, clients namespace by
server, and one consistent name for it is a feature rather than a clash.
Gateway is unchanged, verified over stdio. Outpost is unreleased, so this costs
nothing now and would be a breaking rename later.
Two things this surfaced:
- HelpTopic prepended the product prefix unconditionally, so a platform topic
became outpost_hookdeck_projects and missed. It now tries the exact tool name
first, which is what a caller passing a name from tools/list will send.
- A test asserted the Outpost error must not mention hookdeck_login, on the
grounds that the gateway tool does not exist in that session. That premise is
now deliberately false. Rewritten to assert the error names a tool the session
actually registers, which is the property worth holding.
Note this does not address Gateway's own inconsistency: its product tools are
also hookdeck_-prefixed, which needs a rename and a major bump (#352).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
Three changes from driving the Outpost MCP for real.
**Project name and org were always empty.** Every MCP response carries
active_project_name and active_project_org, but resolution went through
ListProjects, which a project-scoped key from `hookdeck ci` cannot call. It
failed, returned early, and left callers with a bare project id to show. Now it
validates the key first, which works for any credential and returns the name of
the key's own project, and only lists projects when the active one differs.
`hookdeck whoami` has always done it this way. Fixes the Gateway server too,
which had the identical hole.
**The publish credential is now publish-specific**: --publish-api-key and
HOOKDECK_OUTPOST_PUBLISH_API_KEY, and the MCP server no longer reads
HOOKDECK_API_KEY.
That variable means "exchange this for CLI credentials" for `hookdeck ci` and
`listen`, and the CLI encourages exporting it for CI. Reading it here gave one
name two meanings, and worse, let an ambient variable exported for something
else silently register the one tool whose effects cannot be undone: publishing
sends real events to real customer destinations. Enabling that should be
something you typed. The `outpost publish` CLI command is unchanged and still
accepts --api-key / HOOKDECK_API_KEY, because that is an explicit one-shot
action rather than an unattended server.
**Help text** now says switching project affects the session only, unlike
`hookdeck project use`, so an agent can answer honestly when asked whether the
user's CLI was repointed. Signing in does persist, because the user asked for
it. Tool descriptions also tell the model to identify destinations by type and
target rather than by id — Outpost destinations have no name field, so an id is
all a model has unless told otherwise.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
Missed in the previous commit. Caught by generate-reference --check, which is
the point of the check.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
Found by driving the MCP server against real projects.
**Publish followed the credential, not the active project, and said nothing.**
The publish credential is fixed when the server starts; the active project moves
with hookdeck_projects use. When they disagreed, publishing for a tenant that
existed in the active project was accepted with a 202 and an event id, matched
nothing, was never delivered, and did not appear in any event list. The response
looked like a success and reported the active project in its meta, which read as
confirmation the event landed where the caller was looking. It had not.
Publishing now checks the tenant first, using the publish credential, so the
lookup resolves to the same project the event would go to. That also catches a
mistyped or unprovisioned tenant, which the API otherwise accepts rather than
rejects.
One subtlety worth recording: the check must not send the project header.
Publishing resolves the project from the credential alone, but resource reads
also honour the header — so leaving it set checks a different project from the
one being published to, and returns a 401 that hides the answer entirely.
**Validation errors carried no detail.** The API returns
{"message":"validation error","data":["topic is invalid"]}, but ErrorResponse
parsed only the message, so every 422 surfaced as a bare "validation error" with
nothing to act on. The data array is now appended, which improves every command,
not just publish.
**A publish that matches nothing now says so.** Zero matched destinations means
the event is not delivered and never appears in the events list, so there is no
artifact to inspect afterwards. The result now carries a warning rather than
looking like an ordinary success.
Not addressed here, both API-side rather than CLI: publishing for a
non-existent tenant returns 202 rather than an error, and an event matching no
destinations is not persisted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
The unit tests were updated when login and projects moved to the hookdeck_
prefix; this acceptance test was missed and still asserted outpost_login. It
now also asserts the product-prefixed names are absent, so the rule is pinned
from both directions rather than only one.
Caught by running the tagged suite locally, which is the point of doing so
before pushing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
The action / actionSet / toolSpec / dispatch pattern was package-private in
pkg/outpost/mcp, so a second server could not reuse it. Move it to
pkg/mcpcore/toolspec.go as exported Action, ActionSet, ToolSpec and Dispatch,
and port the Outpost server onto the exported versions.
The read-only description suffix hardcoded a reference to outpost_help. It now
comes from Server.HelpToolName(), so each product points at its own help tool.
Outpost behaviour is unchanged: pkg/outpost/mcp/tools_test.go passes with only
identifier renames.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
…to gateway_
Port the Event Gateway MCP tools onto mcpcore.ToolSpec, so their schemas are
built from an action set rather than hand-written, and add the write actions
every one of them was missing. The API client already had every method; this is
tool-layer work only.
Write mode is off by default. In read-only mode the write actions are absent
from the action enum and from the tool description, so an agent is never offered
something it cannot do; mcpcore.RequireWrite sits behind that as defence in
depth. Enable with --allow-write or HOOKDECK_MCP_ALLOW_WRITE=true; --read-only
is accepted and wins if both are passed. resolveAllowWrite is now shared with
the Outpost server rather than duplicated.
Actions added:
connections create, upsert, update, delete, enable, disable
sources create, upsert, update, delete, enable, disable
destinations create, upsert, update, delete, enable, disable
transformations create, upsert, update, delete, run
events retry, cancel, mute
requests retry
issues update, dismiss
pause and unpause deliberately stay read-mode actions. Read-only is the mode
people investigate incidents in, and stopping a misbehaving connection is the
natural end of an investigation; both are reversible and drop nothing. The
rationale is recorded at the action definition.
transformations run is gated as a write even though it stores nothing: it
executes caller-supplied code, and a read-only session should not be able to
cause that.
BREAKING CHANGE: the nine product tools and the help tool are renamed from
hookdeck_* to gateway_*. Per-tool permission grants and allowedTools config do
not survive a rename, so every user must re-grant them.
hookdeck_connections -> gateway_connections
hookdeck_sources -> gateway_sources
hookdeck_destinations -> gateway_destinations
hookdeck_transformations -> gateway_transformations
hookdeck_requests -> gateway_requests
hookdeck_events -> gateway_events
hookdeck_attempts -> gateway_attempts
hookdeck_issues -> gateway_issues
hookdeck_metrics -> gateway_metrics
hookdeck_help -> gateway_help
hookdeck_login and hookdeck_projects are unchanged: signing in and switching
project are Hookdeck operations whichever product's server you are in.
gateway_help is now generated from the tool specs, so it reports the current
mode and can no longer advertise an action the session cannot perform.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
Unit coverage in pkg/gateway/mcp/write_mode_test.go, mirroring the Outpost
suite: the action enum and tool description in each mode, the read-only and
destructive annotations, the handler-level guard refusing every write action
without --allow-write, and successful write calls asserted on the request the
handler sends rather than on "did not error".
Two tests exist specifically to hold the pause/unpause decision in place:
pause and unpause stay in the read-only action enum, and calling them against a
read-only server is not refused. If someone later gates them, these fail.
Acceptance coverage under the existing mcp tag: tools/list omits the write
actions without the flag and includes them with it, the renamed tools are
advertised and the old hookdeck_ product names are not, and gateway_help
reports the current mode.
README documents read-only-by-default, --allow-write, the pause/unpause
exception, and the full per-tool action table.
Both tagged suites pass locally: -tags=mcp and -tags=outpost.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
Ten of the Outpost MCP write actions had only their read-only refusal
covered — tenants delete/token/portal, destinations create/update/delete/
enable/disable and the two custom-domain writes. Four are annotated
destructive. Nothing proved any of them worked, so an agent running with
--allow-write would have been the first caller.
Several reads were never called at all: outpost_attempts with any action,
outpost_config get and custom_domain_get, destinations get, events list/get,
destination_types get, metrics events, topics and status.
The new tests assert the request that goes on the wire — method, path,
query and body — rather than only that the call did not error, because a
stub server answers whatever it is asked and would hide a wire-shape bug.
TestEveryActionHasBeenCalledSuccessfully is a checklist that fails when a
new action lands without a successful call written for it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
The client request types and the outpost_destinations MCP tool both
supported destination metadata, but the CLI exposed no way to set it, so
the same field was reachable through an agent and not through a person.
`outpost tenant upsert` already had --metadata/--metadata-file; this brings
destinations to the same shape and shares one resolver between them rather
than keeping a second copy.
Metadata alone now counts as an update, and --filter's "replaced wholesale,
not merged" note covers metadata too.
Adds unit coverage driving the commands' RunE against a stub Outpost API:
`outpost config set` was previously only ever run with --dry-run, because
the acceptance project's config is shared with every other test in that
file, which left the PATCH body — including how --unset encodes as null —
with no coverage at all. Also covers config get, the custom-domain
commands, tenant portal, and empty-value rejection on outpost flags.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
…om domain
The tenant portal and the three custom-domain commands had no automated
coverage of any kind. Adding it surfaced two defects.
An error body whose "data" member is an object failed to decode, so the
whole envelope was passed through to the user as raw JSON with the readable
sentence buried inside it. That is the shape used for not-found and for
several rejected-value errors, so it affected a large share of the Outpost
errors anyone would actually hit. ErrorResponse now accepts every shape the
API returns.
`outpost tenant portal` answers 404 whenever the project has no portal,
which reads as a missing tenant. It now names the precondition its own help
already documents, and the command to fix it.
The new acceptance test configures a custom domain rather than being gated
behind an opt-in env var, because an opt-in would not run in CI and these
are the commands with the least coverage. Prior state is read first and
restored in t.Cleanup, and the hostname is unique per run.
Also covers tenant list pagination, which accepted --next and --prev but
had never been sent one, and `destination-type get` with an unknown type.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
The outpostlive build tag appeared in no workflow, so the only test proving
the Outpost API client works against a real host had never run automatically
— silent non-execution rather than a considered decision.
It stays out of the pull-request matrix on purpose: it makes real requests
to a live deployment, so a deployment problem would fail every unrelated PR.
A nightly schedule plus workflow_dispatch keeps it honest without coupling
it to the PR gate.
The tests skip themselves when the key is absent, which is right locally and
wrong in CI, so the job fails fast on a missing secret rather than reporting
a green run that tested nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
An outpost command run against a non-Outpost project said to run
'hookdeck project use'. With a project-scoped credential that command
refuses and says to sign in again — and signing in with the same key lands
back on the original error. Three commands, no way out, on the path every
new user takes.
The guard now establishes whether the credential can switch projects at all
before advising, and when it cannot, names the two things that do work:
signing in with an account-wide key, or pointing the machine at the Outpost
project with its own API key. The extra request is only made on a path that
has already failed, and a failure to make it falls back to the previous
advice rather than compounding one error with another.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
…llel run
A project's custom domain is a single value, so two acceptance runs against
the same project can overwrite each other's. The hostname is still asserted
on the fast path, immediately after set; the slow poll now only waits for a
portal URL to exist, and cleanup only removes the domain this test set.
Otherwise a collision between runs would be reported as a CLI defect.
Documents --metadata on tenants and destinations in the README.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
`hookdeck project use` reported success and then wrote somewhere else.
The command re-derived its own write target: it checked whether the
working directory contained a .hookdeck/config.toml and, if so, wrote
there — without ever asking whether --hookdeck-config had been given.
Reads went to the named file, writes went to the local one, so the next
command run with the same flag still reported the old project, and a
local config that happened to be in the directory was overwritten and
its contents lost.
--hookdeck-config is how you keep a command off your own configuration;
that is what scripts and test tooling pass it for. It has to hold for
writes as well as reads or it guarantees nothing.
Config resolution already implements the intended rule in exactly one
place, config.getConfigPath: an explicit path (--hookdeck-config, or
HOOKDECK_CONFIG_FILE) beats a cwd-local config, which beats the global
config. The fix is to stop duplicating that lookup in the command and
write the file the invocation loaded. --local keeps its own meaning —
pin both reads and writes to ./.hookdeck/config.toml — and remains an
error in combination with --hookdeck-config.
Audited every other site that resolves a config path for reading or
writing: login, ci, logout, listen, gateway, outpost and the MCP login
tool all persist through Profile.SaveProfile, which writes the resolved
file, so `project use` was the only command with the defect. The two
`--local` write paths (UseProjectLocal, via saveLocalConfig) are correct
for the flag they serve. Comments on UseProject, UseProjectLocal and
getConfigPath now say which is which, so the lookup does not get
reinvented in a command again.
BREAKING CHANGE: `hookdeck project use --hookdeck-config <path>` run
from a directory containing .hookdeck/config.toml used to write that
local file and leave <path> untouched. It now writes <path>, and never
touches the local file. Anyone relying on the old behaviour to update a
local config while passing --hookdeck-config should use --local (or no
flag) instead.
Precedence is now documented in the --hookdeck-config and --local flag
help, in REFERENCE.md and in the README.
Tests: pkg/cmd/project_use_config_target_test.go covers all four cases
(--hookdeck-config with a cwd-local config present, --local, neither
with and without a cwd-local config, and the rejected combination)
against a stubbed API. The regression case fails on the pre-fix code —
the explicit file keeps the old project id, the local file gets the new
one, and the reported path is the local file. Acceptance coverage added
for the same rule end to end against the real API: project use (needs a
user-associated CLI key) and ci (the sibling command) in
test/acceptance.
Fixes#424
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Slice 0 carried eleven tags and ran 22 minutes while slices 1 and 2
finished in about five. It had looked balanced because `mcp` — 10
minutes of live API calls on its own — was failing instantly on stale
tool names, so its real cost never appeared.
Split against measurement rather than guesswork, taking per-test
durations from a green run and grouping them by each test file's build
tag: mcp 10.3, connection 3.8, listen 2.9, connection_upsert 2.2,
source 0.8, connection_update 0.4, connection_list 0.4, basic 0.4,
connection_oauth_aws 0.3, project_use 0.3, connection_error_hints 0.1,
guest 0.0.
Slice 3 takes none of the overflow: its key is an Outpost project and
the Gateway tests cannot run against one. So 32.5 minutes of Gateway
work spreads across the three Gateway keys, landing each slice near 11
minutes and roughly halving the critical path.
No new secrets. A slice's API key is what keeps concurrent jobs off each
other's records, so the number of slices is bounded by the number of
project keys; balancing within the existing three is the change that
needs no provisioning.
The method is recorded in the workflow so the next person re-measures
instead of guessing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
gateway_destinations_write offered limit, next and prev; gateway_sources_write
the same; outpost_config_write offered a key described as "a single
configuration key to read (get)" on a tool with no get action. Dead
parameters an agent can be led into passing.
Two causes, both fixed.
Property scoping honoured Only and Write but ignored Actions, so a
property declared Actions: ["list"] was still advertised on a group with
no list. VisibleProps now drops a property whose declared actions are
none of the ones the group offers.
And six tools declared limit/next/prev with no Actions at all, so no
amount of scoping could help them: gateway events, requests and
transformations, and outpost attempts, events and tenants. Each now
names the actions that actually paginate — gateway events lists both,
since list_ignored pages too.
TestWriteModeRestoresTheHiddenProps asserted the write group advertised
every property and never had fewer than the read group. That held when
one tool hid its write properties until --allow-write, but the split
made them separate tools with different jobs, and a write tool dropping
list's pagination is now correct. Replaced with the invariant that
survives: every declared property must be advertised by at least one of
the tools the spec splits into, or a handler reads something no caller
can be told to send.
Refs #363, which is the mirror of this and partly addressed by the
split.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both `gateway mcp` and `outpost mcp` documented a one-liner that piped a
JSON-RPC initialize request in. It printed nothing and exited non-zero:
$ echo '{"jsonrpc":"2.0",...}' | hookdeck gateway mcp
server is closing: EOF
The server exits on EOF, and a bare echo closes stdin before the reply is
written. Exiting on EOF is right for a stdio server — a real client holds
stdin open — so the example is what was wrong, not the behaviour.
Keeping stdin open for a moment returns the expected initialize result,
verified against both servers.
Closes#428
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TestOutpostTenantPortalAndCustomDomain polls for 90 seconds and, on
failure, reported only "the portal never became available after setting a
custom domain". It discarded the error and both streams on every attempt,
so eighteen tries produced no evidence: a propagation delay, an auth
problem and the API refusing the domain outright all failed identically.
Records the attempt count and the last error, stdout and stderr, and
names the hypothesis worth checking first — the domain this test sets is
never DNS-validated, so if the deployment has started requiring a
validated domain before serving the portal, no window is long enough.
Diagnosis only. It does not widen the window, because 90 seconds should
be enough and widening it before knowing the cause would hide whatever
this is.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The portal test failed in CI on three consecutive runs while passing
when run on its own, and the failure said only "the portal never became
available".
Instrumenting the poll answered it. The window was 90 seconds; the first
portal call after setting a custom domain took 40 attempts, about 195
seconds. Run alone it had come back at roughly 85, which is exactly why
90 looked sufficient and why it failed the moment anything ran before
it. The domain was `Status: active` throughout, and every call after the
first is immediate — so this was propagation, not state left behind by
another test and not the never-DNS-validated domain the earlier comment
suspected.
Five minutes, with the measurement recorded so the next person changing
it knows what it was chosen against.
Two fixes to the diagnosis it produced along the way:
require.Eventually evaluates its message arguments at call time, before
the condition has run, so the first attempt at instrumenting this
reported "attempts: 0, last error: <nil>" after a full 90 seconds of
polling. Polled by hand instead, so the message is built when it fails.
The failure now also reports the project's domain at that moment, which
is what ruled out the competing explanation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both findings from the Copilot review of #368, both confirmed.
Request-body redaction matched one top-level field, guest_api_key, and
response bodies and headers were logged verbatim. That is where the
credentials are: an Outpost destination carries caller-supplied secrets
under a nested "credentials" object, an API key create returns the
secret as "key", a tenant token response is nothing but a token, and a
webhook destination comes back with its platform-generated signing
secret. Anyone running with debug output on wrote all of them to disk,
for as long as the log survived.
Redaction is now recursive and matches by field name at any depth,
because the same secret arrives at different depths on different routes,
and it runs over responses as well as requests. Response headers go
through the header redactor too, which now covers X-Api-Key, Set-Cookie,
Cookie and Proxy-Authorization rather than Authorization alone.
A body with nothing to redact is still logged byte for byte, so debug
output stays faithful where it costs nothing. A body that is not JSON is
returned unchanged: it may be a raw webhook payload and there is no
structure to search.
Also in tools/credential-matrix/.env.example: it told the operator to
grep the CLI session key out of config.toml, which puts a reusable
credential into terminal scrollback and any shell log. It now says to
open the file and copy the value.
And it named `hookdeck login --api-key` as the project-scoped
alternative. --api-key is a hidden legacy root flag, not login's own;
the documented exchange is `hookdeck ci --api-key`. Corrected, which is
the same correction made in review of the tool itself and missed here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
From the Copilot review of #368. The test asserted only that every row
it got back matched the filter, which is vacuous on an empty page: a
filter that never reached the endpoint could pass simply because the
query returned nothing. The setup also created a request on one side of
the boundary and asserted against both.
It now resolves the request that produced the event it just triggered,
requires the events-count 1 page to contain that id, and requires the
events-count 0 page NOT to contain it. If the filter were dropped the
API would return every request, this one among them, so its absence is
what proves the filter arrived.
To be precise about what changed: the old per-row assertion would still
have failed on a dropped filter whenever the page came back non-empty
and mixed. What it could not do was fail reliably. Anchoring on an id
the test created makes the result deterministic rather than dependent on
what else happens to be in the project.
Verified by deliberately dropping events_count from the request list
params: the new assertion fails, and passes again once restored.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An argument an action ignores is the failure this package keeps finding: a
call that succeeds while silently dropping what the caller asked for, and a
result that reads as filtered without being filtered.
rejectUnknownArgs has always been able to catch that — it calls
prop.appliesTo(action) and refuses an argument the requested action does not
read. It just had almost nothing to check, because only the paging filters
declared Actions. So gateway_attempts_read accepted {"action":"get",
"order_by":"created_at"} and handed back an attempt as though a sort had been
applied; outpost_events_read accepted {"action":"get","topic":"orders"} and
returned the event whatever its topic; outpost_config_write accepted values on
custom_domain_delete and deleted the domain; outpost_tenants_write accepted
metadata on token and minted a credential instead of storing anything.
Each property's scope is derived from the handler that reads it, not from its
description. Where the two disagreed, the handler won and the description is
corrected:
- gateway sources/destinations/transformations `name`: update reads it too,
so it renames; the description said list/create/upsert only.
- gateway connections `id`: create and upsert key on name and never read it,
so "required for every action except list" was wrong twice over.
- gateway connections `name`: connectionRef accepts it in place of id on
every action that addresses one connection, which the description omitted.
- outpost attempts `tenant_id` / `destination_id`: attemptsGet reads both to
pick the tenant-scoped route, so they are not list-only filters.
- outpost destinations `type`: destinationsUpdate never reads it — a
destination's type cannot be changed — so it is create and list only.
Also corrects the outpost destinations `credentials` description, which
claimed every value is write-only. Caller-supplied credentials are masked on
read, but a webhook destination's platform-generated signing secret is
returned, and a caller needs it to verify signatures.
BEHAVIOUR CHANGE: a call that previously succeeded while silently discarding
an argument now returns an error naming the argument and the actions that do
take it. That is the point — the alternative is a plausible wrong answer — but
it will break callers that were passing ignored arguments.
Scopes are also what VisibleProps uses to decide a group's schema, so the
write tools stop advertising read-only filters (gateway_issues_write no longer
offers order_by or filter_status, gateway_transformations_write no longer
offers connection_id or request) and the pause tool no longer offers the
connection body fields.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The strengthened filter test in c3fc58e failed in CI while passing
locally, and the difference was speed: 6 seconds on the runner against
33 here.
events_count is derived. The setup waits for the event to exist, not for
the request's counter to catch up, so querying immediately can miss the
request through no fault of the filter. The assertion was sound; the
timing assumption under it was not.
Polls for up to 60 seconds for the request to appear under
--events-count 1, then asserts as before. By hand rather than with
require.Eventually, which evaluates its message arguments before the
condition runs and would have reported an empty list whatever came back
— the same trap that made the first attempt at instrumenting the Outpost
portal test useless.
Re-verified both directions: passes against the live API, and still
fails when events_count is deliberately dropped from the request list
params.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
These wrong-ID hints still return the pre-split tool names. Passing a connection/source/destination ID therefore directs the agent to an unknown tool instead of the registered _read tool. Update the hints (ideally using shared constants) to keep runtime guidance consistent with the v3 surface.
Update wrong-ID hints to reference registered _read tools
pkg/gateway/mcp/tool_request.go:76
These wrong-ID hints name the removed unsuffixed tools. If a connection/source/destination ID is passed here, the agent is told to call a tool that v3 does not register. Point these entries at the corresponding _read tools (preferably via shared constants) so the recovery path is callable.
Three findings from the second Copilot review of #368, all confirmed.
**A browser login could hand a server the wrong product.** The flow
lands on whichever project the user was last in, and the handler adopted
it unconditionally. ProjectFilter is enforced by the projects tools
alone — resource handlers check only RequireAuth — so an Outpost server
could complete login on a Gateway project and every later call would
reach the wrong product and come back 404: data that reads as missing
rather than a project that was never right. The CLI has this gate at
startup; the MCP login path did not.
Refused before anything is persisted, not after. Writing the mismatched
project would also leave a config the next server start rejects, turning
one wrong answer into a broken session, so the error says plainly that
nothing was saved and how to retry. The decision is extracted as
loginProjectMismatch so it can be tested without standing up a browser
flow.
**Two properties were still unscoped**, both the same defect fixed in
1356712 but in files outside the eleven that review named — my error in
scoping that work to the named files rather than to the class.
projects.project_id is read only by use, so list accepted it and
returned every project as though filtered; destination_types.type is
read only by get, so a type-filtered list returned every type.
A sweep of every remaining unscoped property found no others. The rest
are read by all of their tool's actions, sit on single-action tools, or
belong to the gateway events and metrics tools, whose per-route filter
matrices are enforced in the handler instead.
One test in this commit was vacuous when first written: the stub API
answers list with an error anyway, so asserting only that the call
failed passed with and without the scope. It now asserts the argument
guard's own wording. Both scope tests were checked against the unfixed
files.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The notes described one breaking change, the MCP rename. There are two,
and they hit different people: the rename costs anyone who has granted
tool permissions to an agent a one-time re-grant, while the config-path
fix changes which file `--hookdeck-config` writes — which reaches
scripts and test harnesses, since that flag is how you keep a command
off your own configuration.
Breaking 2 carries the reproduction, the precedence table, and who is
affected, plus the one deliberate asymmetry: only the flag is rejected
alongside --local, not HOOKDECK_CONFIG_FILE. Taken from REFERENCE.md so
the two cannot disagree.
Argument rejection is called out under Breaking 1 rather than as a third
heading. It belongs to the split, but it is the piece a client notices
at runtime instead of at grant time: a call that used to succeed while
discarding an argument now returns an error naming it.
Adds a Fixed section so the notes cite issues rather than commits —
#425, #426, #427, #428 and #363 — and records two findings worth a
reader's attention on their own: debug logs were persisting reusable
credentials, and outpost_events_write accepted tenant_id on retry and
issued the retry anyway while the caller believed it was tenant-scoped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI failed on a docs-only commit, which is what made this visible:
failed to unmarshal JSON output: invalid character 'W' looking for
beginning of value
output: Warning: could not fetch source types for validation: ...
{ "id": "web_4Ko74iD527b9", ...
Source auth validation fetches the OpenAPI spec and, when that fetch
fails, warned with fmt.Printf. stdout is where the command's own output
goes, so the warning landed ahead of the JSON and every consumer of
`--output json` got a parse error instead of a connection.
The warning itself is right to exist — validation is skipped and the API
still checks — it was only on the wrong stream. Now os.Stderr.
The spec fetch fails intermittently, so this broke scripts at random
rather than consistently, and passed CI on every run where the fetch
happened to succeed. It was not caused by the change under test on
either of the runs that caught it.
TestDiagnosticsDoNotGoToStdout guards the class rather than the
instance: no fmt.Print* in pkg/cmd may open with Warning:, Error:,
Deprecated: or Note:. The offending line looked like every other
fmt.Printf in the package, so a reviewer was never going to catch the
next one by eye.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A regression from 1d9e1ad, found by manual QA. That change mapped
connection_id to webhook_id on the way in and rendered the *accepted*
list in the caller's spelling, but left the *rejected* names raw:
gateway_bulk_read {"action":"plan","operation":"requests_retry",
"query":{"connection_id":"web_..."}}
-> webhook_id is not a filter of the requests_retry operation
The caller typed connection_id. Worse, the request operations take no
connection filter under either name, so the message named a token that
appears nowhere in the call and nowhere in the accepted list — nothing
to connect it to.
Both halves now render through DimensionName, which is the rule
metrics_filters.go already documents: anything reported back to the
caller goes through it, or a refusal names a token they never typed.
That comment was there before I made this mistake.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
BEHAVIOUR CHANGE: `hookdeck project create`, `hookdeck gateway
custom-domain`, `hookdeck outpost publish-bogus` and every other unknown
subcommand of a group command now exit non-zero. They used to print the
group's help and exit 0.
v3.0.0 removes `project create/update/delete`, `hookdeck org` and the
custom-domain commands. A script that called one of those before v3 was
getting success and no error — a silent failure exactly where the release
needs a loud one.
Cobra's Command.execute returns flag.ErrHelp for any command with no Run
or RunE, and it does so *before* ValidateArgs; ExecuteC turns that into
"print help, return nil". So `Args: validators.NoArgs` on `project`,
`gateway` and `outpost` never ran. Only the root command reported unknown
commands, via the legacyArgs fallback Find applies when Args is nil.
The fix gives each group command a RunE that prints its help, which makes
it runnable so cobra.NoArgs runs, and applies it by walking the assembled
tree rather than at two dozen constructors — a group command added later
cannot silently reintroduce this. A bare `hookdeck project` still prints
help and exits 0.
Two consequences handled here:
- Group commands now reach their group's PersistentPreRunE, which they
never did while non-runnable. `hookdeck outpost tenant` would have
started demanding an Outpost project just to show its help, so both
gateway and outpost hooks skip the project gate for a group command.
- Execute built the "Unknown command" text from os.Args[1] and the root
command path, which name the unknown word only at the root. One level
down that produced `Unknown command "project" for "hookdeck". Did you
mean "project"?`. It now reads the two names out of cobra's own error
and ranks the spelling suggestions, which cobra returns in
registration order (`... tenant lst` suggested "get" over "list").
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Thirteen Outpost properties said "Accepts an array of strings or a
comma-separated string" while declaring "type": "string":
outpost_tenants_read/write.id, outpost_events_read.{id,topic,tenant_id,
destination_id}, outpost_events_write.{id,destination_id},
outpost_attempts_read.{topic,tenant_id,destination_id,destination_type,
event_id} and outpost_destinations_read/write.type.
outpost_events_read {"action":"list","topic":["a","b"]}
-> topic takes a single value, not an array
mcpcore.checkArgumentTypes refuses an array for a scalar property, and
does so deliberately: its comment says an array where a single value
belongs is "what nothing can consume". The handlers' StringList would
split one, but the request never reaches the handler. So the array half
of the promise was dead on arrival, and the description — the half an
agent reads first — was the one that was wrong.
The descriptions now promise only the comma-separated form, which is what
works, what the handlers split, and what these properties' Event Gateway
equivalents already say. The alternative, widening the schema, would mean
a second multi-type declaration alongside JSONValue and a hole in a
validator both servers share, on a release branch, to reach a spelling
that already exists.
Guarded by TestToolPropertiesDoNotPromiseArrays in both servers, which
fails on any property whose description says "array" over a scalar type —
it reports all thirteen against the unfixed descriptions. The behaviour
the text now promises is pinned by
TestMultiValueFiltersTakeCommaSeparatedNotArrays.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CLI : hookdeck outpost tenant portal <t> --theme purple
-> "--theme must be either light or dark", exit 1
MCP : {"action":"portal","id":"<t>","theme":"purple"}
-> a portal URL
Two things were missing, and either alone would have left the surfaces
disagreeing:
- outpost_tenants_write.theme declared no enum, so there was nothing for
a schema-validating client to check against.
- Nothing enforced an enum anyway. outpost_attempts_read.status has one
and `status: "bogus"` still went out to the API — the enum was
decoration. checkArgumentTypes now rejects a value outside a declared
enum, which is the same failure it already guards everywhere else: an
argument the tool cannot use, accepted and then quietly ignored.
The valid themes move to hookdeck.OutpostPortalThemes so the CLI flag and
the MCP schema read from one list; the CLI's message is unchanged.
"action" is exempt because Dispatch already rejects an unknown action with
a better message — it names the actions available in the current mode and
can point at the sibling tool.
Verified against the unfixed code: without the enum the schema assertion
fails and purple is accepted; with the enum but no enforcement, purple is
still accepted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Splitting each resource into _read and _write left every shared property
with the description it had when the resource was one tool, so the text
went on naming the whole original action list:
outpost_tenants_write.id "On list, filters by tenant ID(s)"
outpost_events_write.id "Required for get/retry"
outpost_destinations_write.type "On list, filters by type(s)"
gateway_connections_write.destination_id "Filters on list"
None of those tools has a list action. Sweeping both servers turned up 69
more of the same on every _read/_write pair — gateway_connections_pause
said its id was "Required for get, pause, unpause, update, delete, enable
and disable" on a tool that offers two of those — plus one aimed at
another tool: gateway_event_read told callers to get an id "from a
request's events action on gateway_request_read", which has get, raw_body
and retry.
Per-tool wording, not deletion: each clause is true on the sibling. Rather
than write a description per group and leave two copies to drift, the
clauses become Prop.ActionNotes, each scoped to the actions it is about
and rendered with only the ones the tool being built actually offers. So
one declaration of tenants.id yields "Required for get." on the read tool
and "Required for upsert/delete/token/portal." on the write one, and an
action moved between groups takes its clause with it. Rendering happens in
VisibleProps, so the help topic and the schema cannot disagree.
Two guards, beside TestAgentFacingTextNamesOnlyRealTools, which this is
the action-level version of:
- TestAgentFacingTextNamesOnlyRealActions, per tool, against the
resource's own action vocabulary so ordinary prose ("upsert is the
way to create one" on a resource with no create) stays prose.
- TestAgentFacingTextNamesOnlyRealToolActions, for prose pointing at
another tool's action.
They report 91 problems against the unfixed descriptions. The detector
has its own tests, because a guard that quietly stops matching is worse
than none.
One existing test changed: TestHelpTopic/"write topics document the write
actions" asked for the _read topic and passed because that tool's id
description named the write actions. It now reads the write topic, which
is what its assertions were always about.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TestEventMute failed in CI with "expected at least one event after
trigger (waited ~20s)" while TestEventRetry and TestEventCancel, calling
the same helper seconds earlier, returned in about seven. Re-running the
slice passed, so it is a flake rather than a regression — but the flake
sits in shared setup that 48 tests depend on.
Three helpers carried the same 10 x 2s window: events, requests and
attempts after a trigger. All now poll for 60 seconds through named
constants, so the next person changing one changes all three.
The number is not arbitrary. This is the third time on this branch that
a propagation window set from a quiet machine has been wrong: a derived
events_count read immediately after its event existed, and a tenant
portal that took 195 seconds against a 90-second window. On the run that
verified this change TestEventRetry took 28.9 seconds end to end, past
the old ceiling for the whole test.
Widening costs nothing on the happy path — every loop returns the moment
the record appears, so a test that took 7 seconds still takes 7. What it
buys is that a third of the acceptance suite no longer depends on a
guess.
The failure messages now report how many attempts over how long, rather
than a hardcoded "~20s" that would have gone stale the first time
someone changed the loop. And the comment says what to do if one of
these starts timing out consistently: measure it, because that is the
API's behaviour changing, not a reason to double the number again.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The manual QA skill told you how to run safely and what to check on each
surface, and nothing about where to point a pass. Both v3.0.0 passes were
steered by a human handing over a list of recently-changed areas. That
does not scale, and it aims the pass at what somebody already suspected —
the defects those passes found were mostly *around* the named areas, not
in them.
references/choosing-what-to-test.md is the missing half, derived from
what the two passes actually found (#423-#436) rather than from what
sounds like good testing advice:
- Eight defect shapes, each with its evidence and how to hunt it: a
capability diverging across CLI and MCP (#425 rules, 489085a theme —
which exposed that enums were never enforced over MCP at all); prose
promising what the code does not do (#426, 471ba21's 13 array
promises, 3f3ae69's 91 misnamed actions, #423, #428); the failure
and empty paths (#432, #433, 131a4e9's 23 commands exiting 0); the
argument nobody passes (#425 again — the most obvious first call
against the new write tool, and the only one no test made);
an input accepted then dropped; stdout pollution breaking
--output json (a00396e); exit codes; state verbs with side effects
nobody wrote down (#431 pause clearing disabled_at, which lets
read-only MCP re-enable a disabled connection).
- A routine that produces targets from the surface listing and the
CLI/MCP diff, so a pass needs no list.
- The honest boundary: what a suite structurally cannot assert —
whether the prose is *true*, cross-surface behaviour, a test that
shares the code's wrong assumption (#425, #397), anything needing a
real browser (#403, #404), and timing, where three propagation
windows set from a quiet machine were each too short under load
(6c629de, a18f715, 373033b).
- A table of what is now guarded — golden tool surface, agent-facing
prose, array promises, action scopes, enum enforcement, stdout
purity, unknown subcommands, --hookdeck-config writes — so a pass
stops re-finding findings that have already become CI failures. Both
checklists now point at it for the same reason.
SKILL.md gains two things the passes paid for. The safety model did not
stop pass 1 destroying a config: the guard protects the config it hands
you, not one it has never heard of, and `project use` wrote the cwd-local
file while honouring --hookdeck-config for reads (#424). Recording an ls
and an md5 of both configs before and after, as pass 2 did, is what makes
"nothing else was touched" a claim with evidence; the underlying bug is
fixed in 3fc8124 so the flag can now be trusted for writes.
And a reporting protocol: exact command, exact output with exit code,
stated expectation, minimal reproduction, and whether the environment was
ruled out. A rate limit masking eleven tests calling pre-split names, and
an ordering-dependent test (#409), were both nearly filed as product
defects in one day.
No behaviour change; documentation only.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
They were drafted for beta.2 and eleven commits had landed since.
Retitled for 3.0.0, and the breaking changes are three rather than two.
The new one is the group-command exit code: an unknown subcommand
printed help and exited 0, so a mistyped or non-existent command
returned success and surfaced later as missing work.
Corrected a framing error while checking that. The draft was heading
towards "v3.0.0 removed commands, so scripts calling them now fail" —
comparing pkg/cmd between v2.6.0 and HEAD shows nothing was removed. The
platform commands were built and parked before release and were never
publicly available, so no script can depend on them. The notes now say
that explicitly rather than implying a removal.
Six fixes added to Fixed: the stdout warning that corrupted
--output json, a bulk refusal naming the wire name instead of the
caller's, the Outpost array promises, the theme validated on one surface
only, and the 91 descriptions naming absent actions. Enum enforcement is
noted under Breaking 1, because declared enums were never enforced at
all and an invalid value that used to reach the API is now refused
locally.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
They were the one part of a 1,100-line planning document with a life
after the release, and they sat at line ~700 of it. Whoever cuts the tag
needs them standalone; nobody needs to read the blind-test evidence to
paste a GitHub Release.
plans/v3.0.0-release-notes.md is now the copy to paste. Headings
promoted a level for a standalone document, and the "see the top of this
document" pointer replaced with a link to the platform-api branch, since
that reference no longer resolved once the section moved.
The plan keeps a pointer rather than the text, and its status changes
from "complete and shipping" to "implemented and shipped in v3.0.0" —
matching the historical banners added to the three older plan docs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The release skill said which sections a release note needs. It did not
say how to write one, so the v3.0.0 draft came out reading like a plan:
a table headed "Who it reaches" categorising readers, and a line stating
that nothing had been removed — a claim nobody asked for, phrased as a
non-event.
references/release-notes-voice.md derives the actual voice from v2.4.0,
v2.5.0 and v2.6.0, with the passages it is drawn from: open with a
Summary in prose and name the shape the fixes share; second person; lead
each entry with the fix in the reader's terms and then explain the
defect; state the consequence concretely and bold it where it is the
point; show real commands and real output; link issues in full rather
than as bare numbers; no tables, which none of those releases use.
It also records the two habits that produced the bad draft. A bare
negative gives the reader nothing to do — v2.5.0 turns the same thought
into "if you have automation that depended on the old behaviour, these
are the ones to check". And labels that categorise the reader rather
than describing the change belong to planning documents.
The v3.0.0 notes are rewritten against it: a Summary that says what the
split buys and what it costs, three breaking sections in prose, fixes
grouped by surface with issues linked in full, and a Full Changelog
line, which the skill requires and the draft was missing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The notes opened on the MCP tool split and never announced Outpost at
all — the largest new capability in the release appeared only
implicitly, through Outpost bug fixes. Someone deciding whether to
upgrade would have read the whole note without learning the command
group exists.
The Summary now leads with what you gain and follows with what the split
costs, and New features carries the command group with a worked example.
Recorded two things the draft would have got wrong by omission: every
Outpost command is marked [BETA], so the flags and output may still
change; and `publish` needs a Project API key because the credentials
`hookdeck login` stores are not accepted by that endpoint. Both verified
against the built binary rather than from memory.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This is a major version: every MCP tool name changes. Outpost support, and a rework of the MCP surface so that "let an agent read, but ask me before it changes anything" is a rule you can actually write.
Breaking change — read this first
Two things happen to tool names, and they land together on purpose.
1. Event Gateway product tools move from
hookdeck_togateway_.hookdeck_loginkeeps its prefix — it is a Hookdeck operation whichever product's server you are in.2. Every resource tool splits by what it does. A tool used to carry an
actionenum mixinglistwithdelete. MCP clients grant permission per tool name, with no ability to match on arguments, so such a tool had to be allowed or denied whole.hookdeck_connectionsgateway_connections_read·gateway_connections_pause·gateway_connections_writehookdeck_sourcesgateway_sources_read·gateway_sources_writehookdeck_destinationsgateway_destinations_read·gateway_destinations_writehookdeck_transformationsgateway_transformations_read·gateway_transformations_writehookdeck_requestsgateway_requests_read·gateway_request_read·gateway_request_writehookdeck_eventsgateway_events_read·gateway_event_read·gateway_event_writehookdeck_attemptsgateway_attempts_readhookdeck_issuesgateway_issues_read·gateway_issues_writehookdeck_metricsgateway_metrics_readhookdeck_helpgateway_helphookdeck_projectshookdeck_projects_read·hookdeck_projects_usehookdeck_loginhookdeck_login(unchanged)Outpost splits the same way:
outpost_tenants_read/_write,outpost_destinations_read/_write,outpost_events_read/_write,outpost_config_read/_write,outpost_publish_write, and read-onlyoutpost_attempts_read,outpost_topics_read,outpost_destination_types_read,outpost_metrics_read,outpost_status_read.Per-tool permission grants and
allowedToolsentries must be updated once. They do not survive a rename and MCP has no migration mechanism.The upside is the reason for the churn. "Allow all reads, prompt on anything that changes data" is now a single rule —
mcp__hookdeck-gateway__*_read— where before it could not be expressed at all. Both renames land in one upgrade deliberately: splitting after GA would have meant a second re-grant.Two deliberate exceptions, both their own tool so the suffix stays honest:
gateway_connections_pause— pause/unpause stay available in read-only mode. Read-only MCP is the incident-investigation tool, and pausing a misbehaving connection is the natural end of an investigation.hookdeck_projects_use— switching the active project changes what every other tool returns, but it is not a write to project data.What's in it
Outpost support — a
hookdeck outpost …command group covering the managed Outpost API (tenants, destinations, events, attempts, publish, topics, destination types, metrics, operator config, custom domain, status), plushookdeck outpost mcp.MCP write mode —
--allow-write(orHOOKDECK_MCP_ALLOW_WRITE), off by default. Write tools are not registered at all without it, so an agent is never offered something it cannot do; asking for a gated action on a_readtool names the flag that would enable it.--read-onlyis accepted explicitly and wins if both are passed.transformations runis a read, checked against the API rather than assumed: a run creates no execution record and returns no execution id. Gating it would leave a session able to read transformation code but unable to try it, which is the debugging work read-only mode exists for.Bulk operations —
gateway_bulk_read/gateway_bulk_writeacross five bulk families (events retry, ignored events retry, requests retry, events cancel, requests cancel).planestimates what an operation would touch without running it, and is a read, so blast radius can be sized with no write access at all. Filters are validated locally against what each operation declares, because the API silently ignores a filter it does not recognise — and "ignored filter" on a bulk retry means running across everything the remaining filters matched.Shared MCP core — the product-agnostic machinery (input parsing, response envelopes, error translation, auth, login/projects tools, telemetry, and the action/write-gating model) lives in
pkg/mcpcore, so the Gateway and Outpost servers no longer carry two copies.Not in this release
Platform management — organizations, project CRUD, custom domains, organization API keys — is built but parked. Probing the live API with all four Hookdeck credentials showed every platform route requires an organization API key: a CLI session key gets
401on all of them, including reading the project it is currently pinned to. Shipping it would have meant publishing commands and tools that answer "Unauthorized" to anyone not carrying an org key.The work is preserved on branch
platform-apipending a decision on the permissions model.hookdeck project listandhookdeck project useare unaffected.API key management is not available through MCP, in any form. A key is a credential; an agent able to mint one could grant itself access the server would otherwise refuse.
Testing
Both servers have a coverage gate that fails when an action ships without a test making a successful call — not merely a test proving it is blocked:
pkg/gateway/mcp: 55 of 55 actions covered, including all 28 write actions (was 7)pkg/outpost/mcp: every action coveredTests assert the request that goes on the wire — method, path, query and body — because a stub server answers whatever it is asked, and every wire-shape defect found during development would have passed a "did not error" assertion. That caught real things:
upsertis aPUTto the collection rather than to an id,updatemust omit fields the caller did not mention, and the MCP-to-API parameter renames (connection_id→webhook_id,connection_ids→webhook_ids,filter_status→status).Four invariants are pinned by tests rather than by review:
TestToolSurfaceIsWhatWeThinkItIsrenders the entire advertised surface in both modes as a golden list, so any tool added, removed, renamed or re-annotated shows as a diff a reviewer has to agree to._reador_writebar the documented exceptions — otherwise*_readis not a usable grant.TestAgentFacingTextNamesOnlyRealToolsreads every tool name mentioned in text an agent sees — descriptions, input schemas, help overview, every help topic — and requires it to exist. Added after manual QA found help text advertisinghookdeck_projects, a name the split had removed; the guard then found six more.Also: a live Outpost smoke suite that previously ran nowhere is now on a nightly schedule.
Notes for review
gateway_connections_writecould not create a connection without an explicitrulesarray, and help text named tools that no longer existed.Closes #425
Closes #426
Closes #427
(#424 and #428 are closed by keywords in their own commits. #363 is only partly addressed by this
branch — the write-only-props-in-read-only-mode half is gone with the compound tools, and its
mirror is fixed here — so it is deliberately left open for a re-read against the current surface.)